@brett_lamy/docstream-editor 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,6 +75,11 @@ export interface GitbookEditorProps {
75
75
  onChange: (markdown: string) => void
76
76
  onKeyDown?: (event: KeyboardEvent) => boolean
77
77
  onPaste?: (event: ClipboardEvent) => boolean
78
+ imagePaste?: "inline" | "chip"
79
+ attachments?: EditorAttachment[]
80
+ onAttachmentAdd?: (attachment: EditorAttachment & { file: File }) => void
81
+ onAttachmentRemove?: (ids: string[]) => void
82
+ onAttachmentOpen?: (attachment: EditorAttachment) => void
78
83
  }
79
84
  ```
80
85
 
@@ -82,6 +87,8 @@ export interface GitbookEditorProps {
82
87
  - `onChange`: Called with serialized markdown whenever TipTap content changes.
83
88
  - `onKeyDown` / `onPaste`: Optional host hooks that run before the built-in editor behavior;
84
89
  return `true` when the application handled the event.
90
+ - `imagePaste`, `attachments`, `onAttachmentAdd`, `onAttachmentRemove`, `onAttachmentOpen`:
91
+ how pasted/dropped images are handled — see [Pasted and dropped images](#pasted-and-dropped-images).
85
92
 
86
93
  The editor tracks the last markdown it emitted so normal controlled updates do not continuously reset the TipTap document. Passing a different external `markdown` value replaces the editor content.
87
94
 
@@ -109,6 +116,65 @@ The editor supports common ProseMirror/TipTap content plus GitBook-flavored bloc
109
116
 
110
117
  Some blocks are intentionally represented as structured nodes rather than fully bespoke editing controls. They are preserved through parse, edit, and serialize flows so the document can continue to round-trip as GitBook-style markdown.
111
118
 
119
+ ## Mentions, channels, and codebases
120
+
121
+ Typing `@`, `#`, or `$` opens a picker and inserts a reference chip that serializes as
122
+ `@id`, `#id`, or `$id` (`$org/repo` works too). Feed each trigger a static list or an
123
+ async `(query) => options` function; options can carry a `label`, `description`, and
124
+ `group` (rendered as section headers). `$` is only enabled when `codebases` is set, so
125
+ dollar signs in ordinary prose never open a menu. Free-form entry is always offered.
126
+
127
+ ```tsx
128
+ <GitbookEditor
129
+ markdown={md}
130
+ onChange={setMd}
131
+ references={{
132
+ mentions: [
133
+ ...people.map((p) => ({ id: p.handle, label: p.name, description: p.email, group: "People" })),
134
+ ...agents.map((a) => ({ id: a.handle, label: a.name, description: a.harness, group: "Agents" })),
135
+ ],
136
+ tags: async (query) => searchChannels(query), // [{ id: "general", description: "channel" }]
137
+ codebases: async (query) => searchRepos(query), // [{ id: "reading-room" }]
138
+ }}
139
+ />
140
+ ```
141
+
142
+ ## Pasted and dropped images
143
+
144
+ `imagePaste` controls how pasted or dropped image files enter the document. It runs after
145
+ your `onPaste` (return `true` there to take over). Clipboard data that also carries plain
146
+ text, such as a Word or Excel selection, still pastes as text.
147
+
148
+ - `"inline"` (default): an image block with the file as a data URL, the way it will render.
149
+ - `"chip"`: a compact attachment chip at the caret. The host stores the file. The chip
150
+ serializes as `![image.png](attachment:att-xyz)`, shows a thumbnail and size, previews the
151
+ full image on hover, and opens it on click.
152
+
153
+ ```tsx
154
+ const [markdown, setMarkdown] = useState("")
155
+ const [attachments, setAttachments] = useState<EditorAttachment[]>([])
156
+
157
+ <GitbookEditor
158
+ markdown={markdown}
159
+ onChange={setMarkdown}
160
+ imagePaste="chip"
161
+ attachments={attachments}
162
+ // once per image: { id, name, src (data URL), size, type, file }
163
+ onAttachmentAdd={({ file, ...attachment }) => setAttachments((all) => [...all, attachment])}
164
+ // chips deleted, cut or cleared by the user
165
+ onAttachmentRemove={(ids) => setAttachments((all) => all.filter((a) => !ids.includes(a.id)))}
166
+ // optional; omit it to use the built-in full-screen viewer
167
+ onAttachmentOpen={(attachment) => openLightbox(attachment)}
168
+ />
169
+ ```
170
+
171
+ `EditorAttachment` is `{ id, name, src?, size?, type? }`. Chips find their image in
172
+ `attachments` by id, so `src` can be a data URL or an uploaded URL. To remove a chip
173
+ from outside the editor, call `editor.commands.removeAttachment(id)` (get the instance
174
+ from `onEditorReady`). Markdown images with an `attachment:` URL always load as chips,
175
+ whatever the mode. `serializeEditorMarkdown(ast)` is the serializer that writes them in
176
+ this form.
177
+
112
178
  ## Edit a referenced source file
113
179
 
114
180
  `GitbookEditor` edits the Markdown composition, including a structured
@@ -159,6 +225,8 @@ Exports:
159
225
  - `astToTiptap`
160
226
  - `tiptapToAst`
161
227
  - `PMNode`
228
+ - `EditorAttachment`
229
+ - `serializeEditorMarkdown`
162
230
  - `SourceFileEditor`
163
231
  - `SourceFileEditorProps`
164
232
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream-editor",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "TipTap editor for Docstream GitBook-style markdown documents.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -33,7 +33,7 @@
33
33
  "./styles.css": "./src/styles.css"
34
34
  },
35
35
  "dependencies": {
36
- "@brett_lamy/docstream": "0.5.7",
36
+ "@brett_lamy/docstream": "0.7.0",
37
37
  "gpu-lexer": "0.0.2",
38
38
  "lucide-react": "^1.17.0"
39
39
  },
@@ -10,13 +10,23 @@ import {
10
10
  Strikethrough,
11
11
  } from "lucide-react"
12
12
 
13
- import { parseMarkdown, serializeMarkdown, type CitationDef } from "@brett_lamy/docstream/gitbook"
13
+ import { parseMarkdown, type CitationDef } from "@brett_lamy/docstream/gitbook"
14
14
  import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
15
- import { astToTiptap, tiptapToAst, type PMNode } from "./convert"
16
- import { createGitbookExtensions } from "./extensions"
15
+ import {
16
+ attachmentName,
17
+ collectAttachmentIds,
18
+ imageFilesFrom,
19
+ newAttachmentId,
20
+ readFileAsDataURL,
21
+ type EditorAttachment,
22
+ } from "./attachments"
23
+ import { astToTiptap, serializeEditorMarkdown, tiptapToAst, type PMNode } from "./convert"
24
+ import { createGitbookExtensions, type ReferenceSources } from "./extensions"
17
25
  import { EditorRuntimeProvider } from "./runtime"
18
26
  import type { SlashItem } from "./slash-menu"
19
27
 
28
+ export type { EditorAttachment } from "./attachments"
29
+
20
30
  export interface GitbookEditorProps {
21
31
  /** Markdown source. When provided, the editor stays in sync with it (controlled). */
22
32
  markdown?: string
@@ -26,8 +36,11 @@ export interface GitbookEditorProps {
26
36
  toolbar?: boolean
27
37
  /** Built-in "/" slash menu: true (default), false, or a custom item list. */
28
38
  slashMenu?: boolean | { items?: SlashItem[] }
29
- /** The "@" / "#" reference chip pickers: true (default), false, or known id lists. */
30
- references?: boolean | { mentions?: string[]; tags?: string[] }
39
+ /**
40
+ * Reference chip pickers: true (default "@"/"#"), false, or per-trigger sources —
41
+ * static lists or async `(query) => options`. A `codebases` source enables "$".
42
+ */
43
+ references?: boolean | ReferenceSources
31
44
  /** Whether the document is editable (default true). */
32
45
  editable?: boolean
33
46
  /** Placeholder shown in an empty document. */
@@ -47,6 +60,20 @@ export interface GitbookEditorProps {
47
60
  onKeyDown?: (event: KeyboardEvent) => boolean
48
61
  /** Handle clipboard content before the editor's built-in paste behavior. Return true when handled. */
49
62
  onPaste?: (event: ClipboardEvent) => boolean
63
+ /**
64
+ * How pasted or dropped image files enter the document.
65
+ * "inline" (default) — an image block with the file as a data URL, as it will render.
66
+ * "chip" — a compact attachment chip at the caret; the host owns the file via `attachments`.
67
+ */
68
+ imagePaste?: "inline" | "chip"
69
+ /** Chip mode: the host's attachment store. Chips look up their thumbnail, full image and size here by id. */
70
+ attachments?: EditorAttachment[]
71
+ /** Chip mode: fired once per pasted/dropped image with a fresh id and the file read as a data URL. Add it to `attachments`. */
72
+ onAttachmentAdd?: (attachment: EditorAttachment & { file: File }) => void
73
+ /** Chip mode: fired when chips disappear from the document (deleted, cut, cleared) with their ids. */
74
+ onAttachmentRemove?: (ids: string[]) => void
75
+ /** Chip mode: clicking (or Enter/Space on a focused) chip. Omit to open the built-in image viewer. */
76
+ onAttachmentOpen?: (attachment: EditorAttachment) => void
50
77
  /** Receive the underlying TipTap editor instance (and null on teardown). */
51
78
  onEditorReady?: (editor: TiptapEditor | null) => void
52
79
  /** Client used to edit and preview `{% source-ref %}` files. */
@@ -135,6 +162,11 @@ export function GitbookEditor({
135
162
  extensions,
136
163
  onKeyDown,
137
164
  onPaste,
165
+ imagePaste = "inline",
166
+ attachments,
167
+ onAttachmentAdd,
168
+ onAttachmentRemove,
169
+ onAttachmentOpen,
138
170
  onEditorReady,
139
171
  sourceClient,
140
172
  sourcePreview = true,
@@ -149,6 +181,56 @@ export function GitbookEditor({
149
181
  // ones from the last applied markdown and reattach them on serialize.
150
182
  const citationsRef = useRef<CitationDef[] | undefined>(undefined)
151
183
  const controlled = markdown !== undefined
184
+ // Ids of the attachment chips in the document, to report removals.
185
+ const attachmentIds = useRef<Set<string>>(new Set())
186
+ // Latest callbacks for handlers the editor captured at creation.
187
+ const latest = useRef({ onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove })
188
+ latest.current = { onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove }
189
+ const editorRef = useRef<TiptapEditor | null>(null)
190
+
191
+ // Pasted/dropped image files: image blocks ("inline") or attachment chips ("chip").
192
+ const insertImageFiles = (files: File[], at?: number) => {
193
+ const ed = editorRef.current
194
+ if (!ed) return
195
+ const insert = (content: PMNode[]) => {
196
+ const chain = ed.chain().focus()
197
+ const pos = at === undefined ? undefined : Math.min(at, ed.state.doc.content.size)
198
+ ;(pos === undefined ? chain.insertContent(content) : chain.insertContentAt(pos, content)).run()
199
+ }
200
+ if (latest.current.imagePaste === "chip") {
201
+ const items = files.map((file) => ({ file, id: newAttachmentId(), name: attachmentName(file) }))
202
+ // Keep chips off the preceding word: "done.[chip]" → "done. [chip]".
203
+ const $at = at === undefined ? ed.state.selection.$from : ed.state.doc.resolve(Math.min(at, ed.state.doc.content.size))
204
+ const before = $at.parent.isTextblock ? $at.parent.textBetween(Math.max(0, $at.parentOffset - 1), $at.parentOffset, "", "\uFFFC") : ""
205
+ const lead: PMNode[] = before && !/\s/.test(before) ? [{ type: "text", text: " " }] : []
206
+ insert([
207
+ ...lead,
208
+ ...items.flatMap(({ id, name }): PMNode[] => [
209
+ { type: "gbAttachment", attrs: { id, name } },
210
+ { type: "text", text: " " },
211
+ ]),
212
+ ])
213
+ for (const { file, id, name } of items) {
214
+ const base = { id, name, size: file.size, type: file.type, file }
215
+ readFileAsDataURL(file).then(
216
+ (src) => latest.current.onAttachmentAdd?.({ ...base, src }),
217
+ () => latest.current.onAttachmentAdd?.(base)
218
+ )
219
+ }
220
+ return
221
+ }
222
+ void Promise.all(
223
+ files.map((file) =>
224
+ readFileAsDataURL(file).then(
225
+ (src): PMNode => ({ type: "gbFigure", attrs: { src, alt: attachmentName(file), caption: "" } }),
226
+ () => null
227
+ )
228
+ )
229
+ ).then((figures) => {
230
+ const content = figures.filter((f): f is PMNode => f !== null)
231
+ if (content.length && !ed.isDestroyed) insert(content)
232
+ })
233
+ }
152
234
 
153
235
  const parseAndTrack = (md: string) => {
154
236
  const doc = parseMarkdown(md)
@@ -168,19 +250,44 @@ export function GitbookEditor({
168
250
  }),
169
251
  editorProps: {
170
252
  handleKeyDown: (_view, event) => onKeyDown?.(event) ?? false,
171
- handlePaste: (_view, event) => onPaste?.(event) ?? false,
253
+ handlePaste: (view, event) => {
254
+ if (latest.current.onPaste?.(event)) return true
255
+ if (!view.editable || view.state.selection.$from.parent.type.spec.code) return false
256
+ const files = imageFilesFrom(event.clipboardData, { ignoreWithText: true })
257
+ if (!files.length) return false
258
+ event.preventDefault()
259
+ insertImageFiles(files)
260
+ return true
261
+ },
262
+ handleDrop: (view, event, _slice, moved) => {
263
+ if (moved || !view.editable) return false
264
+ const files = imageFilesFrom(event.dataTransfer)
265
+ if (!files.length) return false
266
+ event.preventDefault()
267
+ insertImageFiles(files, view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos)
268
+ return true
269
+ },
172
270
  },
173
271
  ...(controlled ? { content: astToTiptap(parseAndTrack(markdown as string)) } : {}),
272
+ onCreate({ editor }) {
273
+ attachmentIds.current = collectAttachmentIds(editor.state.doc)
274
+ },
174
275
  onUpdate({ editor }) {
276
+ const ids = collectAttachmentIds(editor.state.doc)
277
+ const removed = [...attachmentIds.current].filter((id) => !ids.has(id))
278
+ attachmentIds.current = ids
279
+ if (removed.length) latest.current.onAttachmentRemove?.(removed)
175
280
  if (!onChange) return
176
281
  const ast = tiptapToAst(editor.getJSON() as PMNode)
177
282
  if (citationsRef.current?.length) ast.citations = citationsRef.current
178
- const md = serializeMarkdown(ast)
283
+ const md = serializeEditorMarkdown(ast)
179
284
  lastEmitted.current = md
180
285
  onChange(md)
181
286
  },
182
287
  })
183
288
 
289
+ editorRef.current = editor ?? null
290
+
184
291
  // Surface the editor instance to the host (for awareness, commands, etc.).
185
292
  useEffect(() => {
186
293
  onEditorReady?.(editor ?? null)
@@ -194,6 +301,8 @@ export function GitbookEditor({
194
301
  const doc = parseMarkdown(markdown as string)
195
302
  citationsRef.current = doc.citations
196
303
  editor.commands.setContent(astToTiptap(doc), { emitUpdate: false })
304
+ // Chips replaced by the host's own markdown aren't "removed" by the user.
305
+ attachmentIds.current = collectAttachmentIds(editor.state.doc)
197
306
  }, [editor, controlled, markdown])
198
307
 
199
308
  const runtime = useMemo(() => ({
@@ -202,7 +311,9 @@ export function GitbookEditor({
202
311
  sourceAutoSave,
203
312
  ...(onSourceSaved ? { onSourceSaved } : {}),
204
313
  ...(onSourceError ? { onSourceError } : {}),
205
- }), [onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
314
+ ...(attachments ? { attachments } : {}),
315
+ ...(onAttachmentOpen ? { onAttachmentOpen } : {}),
316
+ }), [attachments, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
206
317
 
207
318
  if (!editor) return null
208
319
 
@@ -0,0 +1,354 @@
1
+ import { Node, mergeAttributes } from "@tiptap/core"
2
+ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"
3
+ import { NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps } from "@tiptap/react"
4
+ import { ImageIcon } from "lucide-react"
5
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react"
6
+ import { createPortal } from "react-dom"
7
+
8
+ import { useEditorRuntime } from "./runtime"
9
+
10
+ /** A file the host owns, referenced from the document by an attachment chip. */
11
+ export interface EditorAttachment {
12
+ id: string
13
+ name: string
14
+ /** Full image URL (typically a data URL). Also used for the chip thumbnail. */
15
+ src?: string
16
+ /** Size in bytes. */
17
+ size?: number
18
+ /** MIME type. */
19
+ type?: string
20
+ }
21
+
22
+ /** The URL scheme attachment chips serialize to: `![name](attachment:<id>)`. */
23
+ export const ATTACHMENT_SCHEME = "attachment:"
24
+
25
+ export function newAttachmentId(): string {
26
+ const rand =
27
+ typeof crypto !== "undefined" && "randomUUID" in crypto
28
+ ? crypto.randomUUID().replace(/-/g, "").slice(0, 12)
29
+ : Math.random().toString(36).slice(2, 14)
30
+ return `att-${rand}`
31
+ }
32
+
33
+ /**
34
+ * The name a pasted/dropped file gets in the document. Clipboard screenshots
35
+ * usually arrive as "image.png"; unnamed ones get that too. Characters that
36
+ * would break `![alt](src)` / `alt="…"` are replaced.
37
+ */
38
+ export function attachmentName(file: File): string {
39
+ const name = (file.name || "image.png").replace(/[[\]"<>\r\n]/g, "_").trim()
40
+ return name || "image.png"
41
+ }
42
+
43
+ /** "84 KB", "1.3 MB". */
44
+ export function formatBytes(bytes: number | undefined): string {
45
+ if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return ""
46
+ if (bytes < 1024) return `${bytes} B`
47
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
48
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
49
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
50
+ }
51
+
52
+ export function readFileAsDataURL(file: File): Promise<string> {
53
+ return new Promise((resolve, reject) => {
54
+ const reader = new FileReader()
55
+ reader.onload = () => resolve(String(reader.result ?? ""))
56
+ reader.onerror = () => reject(reader.error ?? new Error(`Could not read ${file.name}`))
57
+ reader.readAsDataURL(file)
58
+ })
59
+ }
60
+
61
+ /**
62
+ * Image files carried by a paste or drop. Clipboard data that also has plain
63
+ * text (e.g. a Word/Excel selection, which ships a rendered PNG alongside the
64
+ * text) is left to the normal text paste.
65
+ */
66
+ export function imageFilesFrom(data: DataTransfer | null, { ignoreWithText = false } = {}): File[] {
67
+ if (!data) return []
68
+ const files = Array.from(data.files ?? []).filter((f) => f.type.startsWith("image/"))
69
+ if (!files.length) return []
70
+ if (ignoreWithText && data.getData("text/plain").trim()) return []
71
+ return files
72
+ }
73
+
74
+ /** Ids of every attachment chip in a document. */
75
+ export function collectAttachmentIds(doc: ProseMirrorNode): Set<string> {
76
+ const ids = new Set<string>()
77
+ doc.descendants((node) => {
78
+ if (node.type.name === "gbAttachment" && node.attrs.id) ids.add(String(node.attrs.id))
79
+ return node.isBlock || node.type.name === "doc"
80
+ })
81
+ return ids
82
+ }
83
+
84
+ // ---------- Hover card ----------
85
+
86
+ const HOVER_DELAY = 150
87
+ const HIDE_GRACE = 120
88
+ const GAP = 8
89
+ const MARGIN = 8
90
+
91
+ function AttachmentHoverCard({
92
+ anchor,
93
+ attachment,
94
+ }: {
95
+ anchor: HTMLElement
96
+ attachment: EditorAttachment
97
+ }) {
98
+ const cardRef = useRef<HTMLDivElement>(null)
99
+ const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
100
+
101
+ const place = useCallback(() => {
102
+ const card = cardRef.current
103
+ if (!card) return
104
+ const a = anchor.getBoundingClientRect()
105
+ const { width, height } = card.getBoundingClientRect()
106
+ const vw = window.innerWidth
107
+ const vh = window.innerHeight
108
+ let top = a.top - GAP - height
109
+ if (top < MARGIN && a.bottom + GAP + height <= vh - MARGIN) top = a.bottom + GAP
110
+ top = Math.max(MARGIN, top)
111
+ const left = Math.min(Math.max(MARGIN, a.left + a.width / 2 - width / 2), Math.max(MARGIN, vw - MARGIN - width))
112
+ setPos({ top, left })
113
+ }, [anchor])
114
+
115
+ useLayoutEffect(place, [place, attachment.src])
116
+
117
+ const size = formatBytes(attachment.size)
118
+ const style: CSSProperties = pos
119
+ ? { top: pos.top, left: pos.left }
120
+ : { top: 0, left: 0, visibility: "hidden" }
121
+ return createPortal(
122
+ <div ref={cardRef} className="gb-attachment-card" style={style} role="tooltip">
123
+ {attachment.src ? (
124
+ <img className="gb-attachment-card-img" src={attachment.src} alt={attachment.name} onLoad={place} />
125
+ ) : (
126
+ <div className="gb-attachment-card-empty">
127
+ <ImageIcon className="size-5" />
128
+ </div>
129
+ )}
130
+ <div className="gb-attachment-card-meta">
131
+ <span className="gb-attachment-card-name">{attachment.name}</span>
132
+ {size && <span className="gb-attachment-card-size"> · {size}</span>}
133
+ </div>
134
+ </div>,
135
+ document.body
136
+ )
137
+ }
138
+
139
+ // ---------- Built-in viewer ----------
140
+
141
+ function AttachmentViewer({ attachment, onClose }: { attachment: EditorAttachment; onClose: () => void }) {
142
+ useEffect(() => {
143
+ const onKey = (e: KeyboardEvent) => {
144
+ if (e.key === "Escape") {
145
+ e.preventDefault()
146
+ e.stopPropagation()
147
+ onClose()
148
+ }
149
+ }
150
+ document.addEventListener("keydown", onKey, true)
151
+ return () => document.removeEventListener("keydown", onKey, true)
152
+ }, [onClose])
153
+
154
+ return createPortal(
155
+ <div
156
+ className="gb-attachment-viewer"
157
+ role="dialog"
158
+ aria-modal="true"
159
+ aria-label={attachment.name}
160
+ onMouseDown={(e) => e.preventDefault()}
161
+ onClick={onClose}
162
+ >
163
+ {attachment.src ? (
164
+ <img className="gb-attachment-viewer-img" src={attachment.src} alt={attachment.name} />
165
+ ) : (
166
+ <div className="gb-attachment-viewer-empty">
167
+ <ImageIcon className="size-6" />
168
+ {attachment.name}
169
+ </div>
170
+ )}
171
+ </div>,
172
+ document.body
173
+ )
174
+ }
175
+
176
+ // ---------- Chip ----------
177
+
178
+ function AttachmentView({ node, selected, editor }: NodeViewProps) {
179
+ const { attachments, onAttachmentOpen } = useEditorRuntime()
180
+ const id = String(node.attrs.id ?? "")
181
+ const stored = attachments?.find((a) => a.id === id)
182
+ const attachment: EditorAttachment = { ...stored, id, name: stored?.name || String(node.attrs.name || "image") }
183
+ const size = formatBytes(attachment.size)
184
+
185
+ const chipRef = useRef<HTMLSpanElement>(null)
186
+ const [hovered, setHovered] = useState(false)
187
+ const [focused, setFocused] = useState(false)
188
+ const [dismissed, setDismissed] = useState(false)
189
+ const [viewing, setViewing] = useState(false)
190
+ const showTimer = useRef<number | undefined>(undefined)
191
+ const hideTimer = useRef<number | undefined>(undefined)
192
+
193
+ const clearTimers = () => {
194
+ window.clearTimeout(showTimer.current)
195
+ window.clearTimeout(hideTimer.current)
196
+ }
197
+ useEffect(() => clearTimers, [])
198
+
199
+ // A fresh selection of the chip re-arms a card dismissed with Escape.
200
+ useEffect(() => {
201
+ if (selected) setDismissed(false)
202
+ }, [selected])
203
+
204
+ const cardOpen =
205
+ !viewing && !dismissed && (hovered || focused || (selected && editor.isFocused))
206
+
207
+ useEffect(() => {
208
+ if (!cardOpen) return
209
+ const hide = () => {
210
+ clearTimers()
211
+ setHovered(false)
212
+ setDismissed(true)
213
+ }
214
+ const onKey = (e: KeyboardEvent) => {
215
+ if (e.key === "Escape") hide()
216
+ }
217
+ window.addEventListener("scroll", hide, true)
218
+ document.addEventListener("keydown", onKey, true)
219
+ return () => {
220
+ window.removeEventListener("scroll", hide, true)
221
+ document.removeEventListener("keydown", onKey, true)
222
+ }
223
+ }, [cardOpen])
224
+
225
+ const open = () => {
226
+ clearTimers()
227
+ setHovered(false)
228
+ if (onAttachmentOpen) onAttachmentOpen(attachment)
229
+ else setViewing(true)
230
+ }
231
+
232
+ const closeViewer = useCallback(() => setViewing(false), [])
233
+
234
+ return (
235
+ <NodeViewWrapper as="span" className="gb-attachment-wrap" contentEditable={false}>
236
+ <span
237
+ ref={chipRef}
238
+ className="gb-attachment"
239
+ role="button"
240
+ tabIndex={0}
241
+ aria-label={size ? `${attachment.name}, ${size}` : attachment.name}
242
+ data-attachment-id={id}
243
+ onMouseEnter={() => {
244
+ window.clearTimeout(hideTimer.current)
245
+ setDismissed(false)
246
+ showTimer.current = window.setTimeout(() => setHovered(true), HOVER_DELAY)
247
+ }}
248
+ onMouseLeave={() => {
249
+ window.clearTimeout(showTimer.current)
250
+ hideTimer.current = window.setTimeout(() => setHovered(false), HIDE_GRACE)
251
+ }}
252
+ onFocus={() => {
253
+ setDismissed(false)
254
+ setFocused(true)
255
+ }}
256
+ onBlur={() => setFocused(false)}
257
+ // Keep the caret where it is — the chip is a button, not a text target.
258
+ onMouseDown={(e) => e.preventDefault()}
259
+ onClick={(e) => {
260
+ e.preventDefault()
261
+ open()
262
+ }}
263
+ onKeyDown={(e) => {
264
+ if (e.key === "Enter" || e.key === " ") {
265
+ e.preventDefault()
266
+ e.stopPropagation()
267
+ open()
268
+ }
269
+ }}
270
+ >
271
+ <span className="gb-attachment-thumb">
272
+ {attachment.src ? (
273
+ <img src={attachment.src} alt="" draggable={false} />
274
+ ) : (
275
+ <ImageIcon className="gb-attachment-thumb-icon" />
276
+ )}
277
+ </span>
278
+ <span className="gb-attachment-name">{attachment.name}</span>
279
+ {size && <span className="gb-attachment-size">{size}</span>}
280
+ </span>
281
+ {cardOpen && chipRef.current && <AttachmentHoverCard anchor={chipRef.current} attachment={attachment} />}
282
+ {viewing && <AttachmentViewer attachment={attachment} onClose={closeViewer} />}
283
+ </NodeViewWrapper>
284
+ )
285
+ }
286
+
287
+ declare module "@tiptap/core" {
288
+ interface Commands<ReturnType> {
289
+ gbAttachment: {
290
+ /** Delete every attachment chip with this id. */
291
+ removeAttachment: (id: string) => ReturnType
292
+ }
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Inline attachment chip (`![name](attachment:<id>)`). The file itself lives in
298
+ * the host's attachment store; the node only carries the id and display name.
299
+ */
300
+ export const GbAttachment = Node.create({
301
+ name: "gbAttachment",
302
+ group: "inline",
303
+ inline: true,
304
+ atom: true,
305
+ selectable: true,
306
+ draggable: false,
307
+ addAttributes() {
308
+ return {
309
+ id: { default: "" },
310
+ name: { default: "" },
311
+ }
312
+ },
313
+ parseHTML() {
314
+ return [
315
+ {
316
+ tag: "span[data-gb-attachment]",
317
+ getAttrs: (el) => ({
318
+ id: (el as HTMLElement).getAttribute("data-gb-attachment") ?? "",
319
+ name: (el as HTMLElement).getAttribute("data-name") ?? "",
320
+ }),
321
+ },
322
+ ]
323
+ },
324
+ renderHTML({ HTMLAttributes, node }) {
325
+ return [
326
+ "span",
327
+ mergeAttributes(HTMLAttributes, { "data-gb-attachment": node.attrs.id, "data-name": node.attrs.name }),
328
+ String(node.attrs.name),
329
+ ]
330
+ },
331
+ renderText({ node }) {
332
+ return String(node.attrs.name)
333
+ },
334
+ addCommands() {
335
+ return {
336
+ removeAttachment:
337
+ (id) =>
338
+ ({ tr, state, dispatch }) => {
339
+ const ranges: Array<[number, number]> = []
340
+ state.doc.descendants((node, pos) => {
341
+ if (node.type.name === this.name && node.attrs.id === id) ranges.push([pos, pos + node.nodeSize])
342
+ })
343
+ if (!ranges.length) return false
344
+ if (dispatch) {
345
+ for (const [from, to] of ranges.reverse()) tr.delete(from, to)
346
+ }
347
+ return true
348
+ },
349
+ }
350
+ },
351
+ addNodeView() {
352
+ return ReactNodeViewRenderer(AttachmentView)
353
+ },
354
+ })
@@ -1,4 +1,19 @@
1
- import { plainText, type Block, type DocumentNode, type Inline, type ListItemNode } from "@brett_lamy/docstream/gitbook"
1
+ import {
2
+ plainText,
3
+ serializeMarkdown,
4
+ type Block,
5
+ type DocumentNode,
6
+ type Inline,
7
+ type ListItemNode,
8
+ } from "@brett_lamy/docstream/gitbook"
9
+
10
+ // Attachment chips live in markdown as images with an `attachment:<id>` URL.
11
+ const ATTACHMENT_SCHEME = "attachment:"
12
+ const isAttachmentSrc = (src: string | undefined) => !!src && src.startsWith(ATTACHMENT_SCHEME)
13
+ const attachmentPM = (src: string, alt: string | undefined): PMNode => ({
14
+ type: "gbAttachment",
15
+ attrs: { id: src.slice(ATTACHMENT_SCHEME.length), name: alt ?? "" },
16
+ })
2
17
 
3
18
  // TipTap/ProseMirror JSON shape (loosely typed on purpose)
4
19
  export interface PMNode {
@@ -23,6 +38,7 @@ function inlineToPM(nodes: Inline[]): PMNode[] {
23
38
  attrs: { kind: n.kind, id: n.id, url: n.url ?? "", label: n.label ?? "" },
24
39
  }
25
40
  }
41
+ if (n.type === "image" && isAttachmentSrc(n.src)) return attachmentPM(n.src, n.alt)
26
42
  if (n.type === "image") {
27
43
  return {
28
44
  type: "gbInlineImage",
@@ -135,6 +151,8 @@ function blockToPM(b: Block): PMNode {
135
151
  content: b.columns.map((c) => ({ type: "gbColumn", content: blocksPM(c.children) })),
136
152
  }
137
153
  case "figure":
154
+ // A chip alone on its line parses as a figure; it is still a chip.
155
+ if (isAttachmentSrc(b.src)) return { type: "paragraph", content: [attachmentPM(b.src, b.alt)] }
138
156
  return { type: "gbFigure", attrs: { src: b.src, alt: b.alt, caption: b.caption } }
139
157
  case "list":
140
158
  return {
@@ -199,11 +217,26 @@ export function astToTiptap(doc: DocumentNode): PMNode {
199
217
  function pmTextToInline(nodes: PMNode[] | undefined): Inline[] {
200
218
  if (!nodes) return []
201
219
  return nodes
202
- .filter((n) => (n.type === "text" && n.text) || n.type === "gbInlineImage" || n.type === "gbReference")
220
+ .filter(
221
+ (n) =>
222
+ (n.type === "text" && n.text) ||
223
+ n.type === "gbInlineImage" ||
224
+ n.type === "gbReference" ||
225
+ n.type === "gbAttachment"
226
+ )
203
227
  .map((n): Inline => {
228
+ if (n.type === "gbAttachment") {
229
+ const a = n.attrs ?? {}
230
+ return {
231
+ type: "image",
232
+ src: `${ATTACHMENT_SCHEME}${String(a.id ?? "")}`,
233
+ ...(a.name ? { alt: String(a.name) } : {}),
234
+ }
235
+ }
204
236
  if (n.type === "gbReference") {
205
237
  const a = n.attrs ?? {}
206
- const kind = a.kind === "tag" ? "tag" : a.kind === "citation" ? "citation" : "mention"
238
+ const kind =
239
+ a.kind === "tag" || a.kind === "codebase" || a.kind === "citation" ? a.kind : "mention"
207
240
  return {
208
241
  type: "reference",
209
242
  kind,
@@ -397,3 +430,19 @@ function pmToBlocks(nodes: PMNode[] | undefined): Block[] {
397
430
  export function tiptapToAst(doc: PMNode): DocumentNode {
398
431
  return { type: "doc", children: pmToBlocks(doc.content) }
399
432
  }
433
+
434
+ // docstream writes inline images as `<img src alt />`; attachment chips read
435
+ // better (and diff better) as plain markdown images.
436
+ const ATTACHMENT_IMG = /<img src="(attachment:[^"\s]+)"(?: alt="([^"]*)")? \/>/g
437
+
438
+ /**
439
+ * Serialize an editor AST to GitBook markdown, writing attachment chips as
440
+ * `![name](attachment:<id>)`. Use this instead of `serializeMarkdown` for
441
+ * editor output.
442
+ */
443
+ export function serializeEditorMarkdown(doc: DocumentNode): string {
444
+ return serializeMarkdown(doc).replace(ATTACHMENT_IMG, (img: string, src: string, alt = "") =>
445
+ // `]` can't be escaped in `![alt]`; such names keep the <img> form.
446
+ alt.includes("]") ? img : `![${alt}](${src})`
447
+ )
448
+ }
@@ -6,7 +6,7 @@ import { getSchema, type AnyExtension } from "@tiptap/core"
6
6
  import type { Schema } from "@tiptap/pm/model"
7
7
  import { GbCodeBlock, gitbookNodes } from "./nodes"
8
8
  import { SlashMenu, createSlashMenu, type SlashItem } from "./slash-menu"
9
- import { createReferenceSuggestion } from "./reference-menu"
9
+ import { createReferenceSuggestion, type ReferenceSource } from "./reference-menu"
10
10
 
11
11
  // Carries GitBook's data-view (e.g. "cards") through the editor untouched.
12
12
  export const GbTable = Table.extend({
@@ -15,15 +15,26 @@ export const GbTable = Table.extend({
15
15
  },
16
16
  })
17
17
 
18
+ export interface ReferenceSources {
19
+ /** "@" — people, agents, … */
20
+ mentions?: ReferenceSource
21
+ /** "#" — channels, tags, … */
22
+ tags?: ReferenceSource
23
+ /** "$" — codebases (`$repo` / `$org/repo`). Enables the "$" trigger. */
24
+ codebases?: ReferenceSource
25
+ }
26
+
18
27
  export interface GitbookExtensionOptions {
19
28
  placeholder?: string
20
29
  /** Built-in "/" slash menu: true (default), false, or a custom item list. */
21
30
  slashMenu?: boolean | { items?: SlashItem[] }
22
31
  /**
23
- * The "@" / "#" reference chip pickers: true (default), false, or lists of
24
- * known ids to offer. Free-form entry always works.
32
+ * The reference chip pickers: true (default: "@" and "#" with free-form entry),
33
+ * false, or per-trigger sources — static lists or async `(query) => options`.
34
+ * "$" (codebases) is only enabled when a `codebases` source is given, so a
35
+ * dollar sign in ordinary prose never opens a menu.
25
36
  */
26
- references?: boolean | { mentions?: string[]; tags?: string[] }
37
+ references?: boolean | ReferenceSources
27
38
  /**
28
39
  * Disable StarterKit's built-in undo/redo. Required when wiring Yjs
29
40
  * Collaboration, which provides its own shared history.
@@ -73,6 +84,7 @@ export function createGitbookExtensions(options: GitbookExtensionOptions = {}):
73
84
  createReferenceSuggestion({ char: "@", ...(opts.mentions ? { items: opts.mentions } : {}) }),
74
85
  createReferenceSuggestion({ char: "#", ...(opts.tags ? { items: opts.tags } : {}) })
75
86
  )
87
+ if (opts.codebases) list.push(createReferenceSuggestion({ char: "$", items: opts.codebases }))
76
88
  }
77
89
 
78
90
  list.push(...extensions)
@@ -13,6 +13,7 @@ import {
13
13
  AtSign,
14
14
  CheckCircle2,
15
15
  ChevronDown,
16
+ FolderGit2,
16
17
  Hash,
17
18
  Info,
18
19
  Link2,
@@ -31,6 +32,7 @@ import { ReplayPreview, isReplayQaUrl } from "@brett_lamy/docstream/replay"
31
32
  import { VideoEmbed } from "@brett_lamy/docstream/video"
32
33
  import { SourceFileEditor } from "./SourceFileEditor"
33
34
  import { useEditorRuntime } from "./runtime"
35
+ import { GbAttachment } from "./attachments"
34
36
 
35
37
  function useDebouncedValue<T>(value: T, delay = 350): T {
36
38
  const [debounced, setDebounced] = useState(value)
@@ -792,7 +794,13 @@ function ReferenceView({ node }: NodeViewProps) {
792
794
  </span>
793
795
  ) : (
794
796
  <span className={`gb-ref gb-ref-${kind}`}>
795
- {kind === "mention" ? <AtSign className="gb-ref-icon" /> : <Hash className="gb-ref-icon" />}
797
+ {kind === "mention" ? (
798
+ <AtSign className="gb-ref-icon" />
799
+ ) : kind === "codebase" ? (
800
+ <FolderGit2 className="gb-ref-icon" />
801
+ ) : (
802
+ <Hash className="gb-ref-icon" />
803
+ )}
796
804
  {id}
797
805
  </span>
798
806
  )}
@@ -818,7 +826,14 @@ export const GbReference = Node.create({
818
826
  return [{ tag: "span[data-gb-ref]" }]
819
827
  },
820
828
  renderHTML({ HTMLAttributes, node }) {
821
- const sigil = node.attrs.kind === "mention" ? "@" : node.attrs.kind === "tag" ? "#" : "^"
829
+ const sigil =
830
+ node.attrs.kind === "mention"
831
+ ? "@"
832
+ : node.attrs.kind === "tag"
833
+ ? "#"
834
+ : node.attrs.kind === "codebase"
835
+ ? "$"
836
+ : "^"
822
837
  return [
823
838
  "span",
824
839
  mergeAttributes(HTMLAttributes, { "data-gb-ref": node.attrs.kind, "data-gb-ref-id": node.attrs.id }),
@@ -969,4 +984,5 @@ export const gitbookNodes = [
969
984
  GbOpenapi,
970
985
  GbInlineImage,
971
986
  GbReference,
987
+ GbAttachment,
972
988
  ]
@@ -1,32 +1,88 @@
1
1
  import { Extension, type Editor, type Range } from "@tiptap/core"
2
2
  import Suggestion from "@tiptap/suggestion"
3
3
  import { PluginKey } from "@tiptap/pm/state"
4
- import { AtSign, Hash } from "lucide-react"
4
+ import { AtSign, FolderGit2, Hash, type LucideIcon } from "lucide-react"
5
5
 
6
6
  import { createSuggestionRender, type SuggestionMenuItem } from "./suggestion-menu"
7
7
 
8
- export type ReferenceTrigger = "@" | "#"
8
+ export type ReferenceTrigger = "@" | "#" | "$"
9
9
 
10
- interface ReferenceItem extends SuggestionMenuItem {
10
+ /** One pickable reference: the chip stores `id`; the rest only decorates the menu. */
11
+ export interface ReferenceOption {
11
12
  id: string
13
+ /** Display name shown next to the id (e.g. "Luna" for `@luna`). */
14
+ label?: string
15
+ /** Secondary line (e.g. an email, "agent · claude-code", a topic). */
16
+ description?: string
17
+ /** Section header; consecutive options with the same group are listed together. */
18
+ group?: string
12
19
  }
13
20
 
21
+ /**
22
+ * Where a trigger's options come from: a static list (filtered by the editor), or a
23
+ * function the host answers per query — sync or async, already filtered.
24
+ */
25
+ export type ReferenceSource =
26
+ | readonly (string | ReferenceOption)[]
27
+ | ((query: string) => readonly ReferenceOption[] | Promise<readonly ReferenceOption[]>)
28
+
14
29
  export interface ReferenceSuggestionOptions {
15
30
  char: ReferenceTrigger
16
- /** Known ids offered in the picker; free-form entry always works. */
17
- items?: string[]
31
+ /** Options offered in the picker; free-form entry works unless disabled. */
32
+ items?: ReferenceSource
33
+ /** Offer inserting the raw query when nothing matches exactly (default true). */
34
+ allowFreeForm?: boolean
35
+ }
36
+
37
+ interface ReferenceItem extends SuggestionMenuItem {
38
+ id: string
18
39
  }
19
40
 
20
- const KIND: Record<ReferenceTrigger, "mention" | "tag"> = { "@": "mention", "#": "tag" }
41
+ const KIND: Record<ReferenceTrigger, "mention" | "tag" | "codebase"> = {
42
+ "@": "mention",
43
+ "#": "tag",
44
+ $: "codebase",
45
+ }
46
+ const ICON: Record<ReferenceTrigger, LucideIcon> = { "@": AtSign, "#": Hash, $: FolderGit2 }
47
+ const EMPTY: Record<ReferenceTrigger, string> = {
48
+ "@": "Type a name to mention",
49
+ "#": "Type a channel or tag",
50
+ $: "Type a codebase",
51
+ }
52
+ const MAX_OPTIONS = 50
53
+
54
+ function normalize(option: string | ReferenceOption): ReferenceOption {
55
+ return typeof option === "string" ? { id: option } : option
56
+ }
57
+
58
+ function matches(option: ReferenceOption, query: string): boolean {
59
+ if (!query) return true
60
+ return [option.id, option.label ?? ""].some((value) => value.toLowerCase().includes(query))
61
+ }
62
+
63
+ /** Resolve a source for a query into options (exported for hosts' tests). */
64
+ export async function resolveReferenceOptions(
65
+ source: ReferenceSource | undefined,
66
+ query: string,
67
+ ): Promise<ReferenceOption[]> {
68
+ if (source === undefined) return []
69
+ if (typeof source === "function") return [...(await source(query))].slice(0, MAX_OPTIONS)
70
+ const q = query.toLowerCase()
71
+ return source.map(normalize).filter((option) => matches(option, q)).slice(0, MAX_OPTIONS)
72
+ }
21
73
 
22
74
  /**
23
- * An `@mention` / `#tag` picker built on @tiptap/suggestion. Filters the host's
24
- * known ids and always offers inserting the raw query, so typing `@anything⏎`
25
- * works with no list configured.
75
+ * An `@mention` / `#tag` / `$codebase` picker built on @tiptap/suggestion. Options come
76
+ * from the host (static or async) and the raw query is offered as a free-form entry,
77
+ * so `@anything⏎` works with no source configured.
26
78
  */
27
- export function createReferenceSuggestion({ char, items = [] }: ReferenceSuggestionOptions) {
79
+ export function createReferenceSuggestion({
80
+ char,
81
+ items,
82
+ allowFreeForm = true,
83
+ }: ReferenceSuggestionOptions) {
28
84
  const kind = KIND[char]
29
- const icon = char === "@" ? AtSign : Hash
85
+ const icon = ICON[char]
30
86
  return Extension.create({
31
87
  name: `${kind}Suggestion`,
32
88
  addProseMirrorPlugins() {
@@ -39,13 +95,26 @@ export function createReferenceSuggestion({ char, items = [] }: ReferenceSuggest
39
95
  // Each Suggestion plugin needs its own key — the default is shared
40
96
  // with the slash menu and would collide.
41
97
  pluginKey: new PluginKey(`${kind}Suggestion`),
42
- items: ({ query }) => {
43
- const q = query.toLowerCase()
44
- const matches = items
45
- .filter((id) => id.toLowerCase().includes(q))
46
- .map((id) => ({ id, title: `${char}${id}`, icon }))
47
- const freeForm = query && !items.some((id) => id.toLowerCase() === q)
48
- return freeForm ? [...matches, { id: query, title: `${char}${query}`, icon }] : matches
98
+ items: async ({ query }) => {
99
+ let options: ReferenceOption[]
100
+ try {
101
+ options = await resolveReferenceOptions(items, query)
102
+ } catch {
103
+ options = []
104
+ }
105
+ const menu: ReferenceItem[] = options.map((option) => ({
106
+ id: option.id,
107
+ title: `${char}${option.id}`,
108
+ icon,
109
+ ...(option.label || option.description
110
+ ? { description: [option.label, option.description].filter(Boolean).join(" · ") }
111
+ : {}),
112
+ ...(option.group ? { group: option.group } : {}),
113
+ }))
114
+ const exact = options.some((option) => option.id.toLowerCase() === query.toLowerCase())
115
+ return allowFreeForm && query && !exact
116
+ ? [...menu, { id: query, title: `${char}${query}`, icon }]
117
+ : menu
49
118
  },
50
119
  command: ({ editor, range, props }) => {
51
120
  ;(editor as Editor)
@@ -58,9 +127,7 @@ export function createReferenceSuggestion({ char, items = [] }: ReferenceSuggest
58
127
  ])
59
128
  .run()
60
129
  },
61
- render: createSuggestionRender<ReferenceItem>(
62
- char === "@" ? "Type a name to mention" : "Type a tag"
63
- ),
130
+ render: createSuggestionRender<ReferenceItem>(EMPTY[char]),
64
131
  }),
65
132
  ]
66
133
  },
@@ -1,6 +1,7 @@
1
1
  import { createContext, useContext, type ReactNode } from "react"
2
2
 
3
3
  import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
4
+ import type { EditorAttachment } from "./attachments"
4
5
 
5
6
  export interface EditorRuntimeOptions {
6
7
  sourceClient?: SourceReferenceClient
@@ -8,6 +9,8 @@ export interface EditorRuntimeOptions {
8
9
  sourceAutoSave?: boolean | number
9
10
  onSourceSaved?: (snapshot: SourceFileSnapshot) => void
10
11
  onSourceError?: (error: Error) => void
12
+ attachments?: EditorAttachment[]
13
+ onAttachmentOpen?: (attachment: EditorAttachment) => void
11
14
  }
12
15
 
13
16
  const EditorRuntimeContext = createContext<EditorRuntimeOptions>({})
@@ -12,11 +12,15 @@ import type { SuggestionProps } from "@tiptap/suggestion"
12
12
  import { ReactRenderer } from "@tiptap/react"
13
13
  import type { LucideIcon } from "lucide-react"
14
14
 
15
- // Shared popup used by the "/" slash menu and the "@"/"#" reference menus.
15
+ // Shared popup used by the "/" slash menu and the "@"/"#"/"$" reference menus.
16
16
 
17
17
  export interface SuggestionMenuItem {
18
18
  title: string
19
19
  icon: LucideIcon
20
+ /** Secondary text shown after the title (e.g. a display name or email). */
21
+ description?: string
22
+ /** Section header; shown when it differs from the previous item's group. */
23
+ group?: string
20
24
  }
21
25
 
22
26
  export interface MenuProps<Item extends SuggestionMenuItem> {
@@ -97,21 +101,26 @@ export const SuggestionMenuView = forwardRef<MenuHandle, MenuProps<SuggestionMen
97
101
  >
98
102
  {items.length === 0 && <div className="slash-empty">{emptyLabel}</div>}
99
103
  {items.map((item, i) => (
100
- <button
101
- key={item.title}
102
- ref={(el) => {
103
- itemRefs.current[i] = el
104
- }}
105
- className={`slash-item ${i === index ? "slash-item-active" : ""}`}
106
- onMouseEnter={() => setIndex(i)}
107
- onMouseDown={(e) => {
108
- e.preventDefault()
109
- command(item)
110
- }}
111
- >
112
- <item.icon className="size-4" />
113
- <span>{item.title}</span>
114
- </button>
104
+ <div key={`${item.group ?? ""}:${item.title}:${i}`} className="slash-row">
105
+ {item.group && item.group !== items[i - 1]?.group && (
106
+ <div className="slash-group">{item.group}</div>
107
+ )}
108
+ <button
109
+ ref={(el) => {
110
+ itemRefs.current[i] = el
111
+ }}
112
+ className={`slash-item ${i === index ? "slash-item-active" : ""}`}
113
+ onMouseEnter={() => setIndex(i)}
114
+ onMouseDown={(e) => {
115
+ e.preventDefault()
116
+ command(item)
117
+ }}
118
+ >
119
+ <item.icon className="size-4" />
120
+ <span>{item.title}</span>
121
+ {item.description && <span className="slash-item-description">{item.description}</span>}
122
+ </button>
123
+ </div>
115
124
  ))}
116
125
  </div>
117
126
  )
package/src/index.ts CHANGED
@@ -1,17 +1,23 @@
1
1
  export { GitbookEditor } from "./editor/Editor"
2
- export type { GitbookEditorProps } from "./editor/Editor"
3
- export { astToTiptap, tiptapToAst } from "./editor/convert"
2
+ export type { GitbookEditorProps, EditorAttachment } from "./editor/Editor"
3
+ export { astToTiptap, tiptapToAst, serializeEditorMarkdown } from "./editor/convert"
4
4
  export type { PMNode } from "./editor/convert"
5
5
 
6
6
  // Building blocks for composing a custom editor (e.g. with Yjs collaboration
7
7
  // or a bespoke slash menu) instead of the batteries-included GitbookEditor.
8
8
  export { createGitbookExtensions, getGitbookSchema, GbTable } from "./editor/extensions"
9
- export type { GitbookExtensionOptions } from "./editor/extensions"
9
+ export type { GitbookExtensionOptions, ReferenceSources } from "./editor/extensions"
10
10
  export { gitbookNodes, GbCodeBlock, GbReference } from "./editor/nodes"
11
+ export { GbAttachment, formatBytes } from "./editor/attachments"
11
12
  export { SlashMenu, createSlashMenu, SLASH_ITEMS } from "./editor/slash-menu"
12
13
  export type { SlashItem } from "./editor/slash-menu"
13
- export { createReferenceSuggestion } from "./editor/reference-menu"
14
- export type { ReferenceSuggestionOptions, ReferenceTrigger } from "./editor/reference-menu"
14
+ export { createReferenceSuggestion, resolveReferenceOptions } from "./editor/reference-menu"
15
+ export type {
16
+ ReferenceOption,
17
+ ReferenceSource,
18
+ ReferenceSuggestionOptions,
19
+ ReferenceTrigger,
20
+ } from "./editor/reference-menu"
15
21
  export { SuggestionMenuView, createSuggestionRender } from "./editor/suggestion-menu"
16
22
  export type { SuggestionMenuItem } from "./editor/suggestion-menu"
17
23
  export { ReactDemoEditor } from "./editor/ReactDemoEditor"
package/src/styles.css CHANGED
@@ -583,14 +583,15 @@
583
583
  }
584
584
  .gb-code > pre code { background: none; padding: 0; font-size: inherit; color: inherit; }
585
585
  /* gpu-lexer span classes (palette shared with docstream's HighlightedCode). */
586
- .gb-code .gb-syntax-comment { color: #6b6b78; font-style: italic; }
587
- .gb-code .gb-syntax-string { color: #a5d6a7; }
588
- .gb-code .gb-syntax-number { color: #f78c6c; }
589
- .gb-code .gb-syntax-keyword { color: #c792ea; }
590
- .gb-code .gb-syntax-type { color: #ffcb6b; }
591
- .gb-code .gb-syntax-function { color: #82aaff; }
592
- .gb-code .gb-syntax-constant { color: #f07178; }
593
- .gb-code .gb-syntax-operator { color: #89ddff; }
586
+ /* Same palette and custom properties as Docstream's .docs-tok-*: follows the host color-scheme. */
587
+ .gb-code .gb-syntax-comment { color: var(--docs-tok-comment, light-dark(#6E6E7A, #8A8A98)); font-style: italic; }
588
+ .gb-code .gb-syntax-string { color: var(--docs-tok-string, light-dark(#23803A, #A5D6A7)); }
589
+ .gb-code .gb-syntax-number { color: var(--docs-tok-number, light-dark(#C2410C, #F78C6C)); }
590
+ .gb-code .gb-syntax-keyword { color: var(--docs-tok-keyword, light-dark(#8839C4, #C792EA)); }
591
+ .gb-code .gb-syntax-type { color: var(--docs-tok-type, light-dark(#A15C00, #FFCB6B)); }
592
+ .gb-code .gb-syntax-function { color: var(--docs-tok-function, light-dark(#2F5BD3, #82AAFF)); }
593
+ .gb-code .gb-syntax-constant { color: var(--docs-tok-constant, light-dark(#C8283F, #F07178)); }
594
+ .gb-code .gb-syntax-operator { color: var(--docs-tok-operator, light-dark(#0B7285, #89DDFF)); }
594
595
 
595
596
  /* ── Updates / changelog (mirrors .docs-updates) ────────── */
596
597
  .gb-updates { margin: 0.85em 0; border-top: 1px solid var(--gb-border); }
@@ -650,6 +651,23 @@
650
651
  .slash-item-active { background: var(--accent, #f4f4f5); }
651
652
  .slash-item svg { color: var(--muted-foreground, #6b7280); }
652
653
  .slash-empty { padding: 8px 10px; font-size: 13px; color: var(--muted-foreground, #6b7280); }
654
+ .slash-group {
655
+ padding: 8px 10px 3px;
656
+ font-size: 11px;
657
+ font-weight: 600;
658
+ letter-spacing: 0.04em;
659
+ text-transform: uppercase;
660
+ color: var(--muted-foreground, #6b7280);
661
+ }
662
+ .slash-item-description {
663
+ margin-left: auto;
664
+ padding-left: 12px;
665
+ overflow: hidden;
666
+ font-size: 12px;
667
+ white-space: nowrap;
668
+ text-overflow: ellipsis;
669
+ color: var(--muted-foreground, #6b7280);
670
+ }
653
671
 
654
672
  .gb-code-live {
655
673
  display: flex;
@@ -819,6 +837,12 @@
819
837
  color: var(--gb-muted-foreground);
820
838
  }
821
839
 
840
+ .gb-ref-codebase {
841
+ border: 1px solid rgba(34, 197, 94, 0.45);
842
+ background: rgba(34, 197, 94, 0.1);
843
+ color: rgb(22, 163, 74);
844
+ }
845
+
822
846
  .gb-cite {
823
847
  display: inline-block;
824
848
  padding: 1px 5px;
@@ -836,3 +860,163 @@
836
860
  outline: 2px solid var(--gb-primary);
837
861
  outline-offset: 1px;
838
862
  }
863
+
864
+ /* ---- Attachment chips (pasted images in imagePaste="chip" mode) ---- */
865
+
866
+ .gb-attachment-wrap {
867
+ display: inline;
868
+ }
869
+
870
+ .gb-attachment {
871
+ display: inline-flex;
872
+ align-items: center;
873
+ gap: 5px;
874
+ max-width: 100%;
875
+ padding: 1px 7px 1px 3px;
876
+ border: 1px solid var(--gb-border);
877
+ border-radius: 7px;
878
+ background: var(--gb-muted);
879
+ color: var(--gb-text);
880
+ font-size: 0.85em;
881
+ line-height: 1.5;
882
+ vertical-align: middle;
883
+ cursor: pointer;
884
+ user-select: none;
885
+ transition: border-color 120ms ease, background-color 120ms ease;
886
+ }
887
+ .gb-attachment:hover {
888
+ border-color: var(--gb-muted-foreground);
889
+ }
890
+ .gb-attachment:focus {
891
+ outline: none;
892
+ }
893
+ .gb-attachment:focus-visible,
894
+ .ProseMirror-selectednode .gb-attachment {
895
+ outline: 2px solid var(--gb-primary);
896
+ outline-offset: 1px;
897
+ }
898
+
899
+ .gb-attachment-thumb {
900
+ display: inline-flex;
901
+ flex-shrink: 0;
902
+ align-items: center;
903
+ justify-content: center;
904
+ width: 20px;
905
+ height: 16px;
906
+ overflow: hidden;
907
+ border-radius: 3px;
908
+ background: var(--gb-border);
909
+ color: var(--gb-muted-foreground);
910
+ }
911
+ .gb-attachment-thumb img {
912
+ display: block;
913
+ width: 100%;
914
+ height: 100%;
915
+ object-fit: cover;
916
+ }
917
+ .gb-attachment-thumb-icon {
918
+ width: 12px;
919
+ height: 12px;
920
+ }
921
+
922
+ .gb-attachment-name {
923
+ overflow: hidden;
924
+ font-weight: 500;
925
+ white-space: nowrap;
926
+ text-overflow: ellipsis;
927
+ max-width: 22ch;
928
+ }
929
+ .gb-attachment-size {
930
+ flex-shrink: 0;
931
+ color: var(--gb-muted-foreground);
932
+ font-variant-numeric: tabular-nums;
933
+ white-space: nowrap;
934
+ }
935
+
936
+ /* Portaled to <body>, outside .gb — tokens resolve from the host directly. */
937
+ .gb-attachment-card {
938
+ position: fixed;
939
+ z-index: 1000;
940
+ box-sizing: border-box;
941
+ max-width: calc(100vw - 16px);
942
+ padding: 6px;
943
+ border: 1px solid var(--border, #e5e7eb);
944
+ border-radius: 12px;
945
+ background: var(--card, #ffffff);
946
+ color: var(--card-foreground, var(--foreground, #111827));
947
+ font-family: var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
948
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.08);
949
+ pointer-events: none;
950
+ animation: gb-attachment-in 120ms ease-out;
951
+ }
952
+ .gb-attachment-card-img {
953
+ display: block;
954
+ max-width: 360px;
955
+ max-height: 280px;
956
+ width: auto;
957
+ height: auto;
958
+ object-fit: contain;
959
+ border-radius: 7px;
960
+ background: var(--muted, #f4f4f5);
961
+ }
962
+ .gb-attachment-card-empty {
963
+ display: flex;
964
+ align-items: center;
965
+ justify-content: center;
966
+ width: 160px;
967
+ height: 100px;
968
+ border-radius: 7px;
969
+ background: var(--muted, #f4f4f5);
970
+ color: var(--muted-foreground, #6b7280);
971
+ }
972
+ .gb-attachment-card-meta {
973
+ max-width: 360px;
974
+ padding: 6px 4px 1px;
975
+ overflow: hidden;
976
+ font-size: 12px;
977
+ line-height: 1.4;
978
+ white-space: nowrap;
979
+ text-overflow: ellipsis;
980
+ }
981
+ .gb-attachment-card-name { font-weight: 500; }
982
+ .gb-attachment-card-size { color: var(--muted-foreground, #6b7280); }
983
+
984
+ .gb-attachment-viewer {
985
+ position: fixed;
986
+ inset: 0;
987
+ z-index: 1001;
988
+ display: flex;
989
+ align-items: center;
990
+ justify-content: center;
991
+ background: rgba(0, 0, 0, 0.72);
992
+ cursor: zoom-out;
993
+ animation: gb-attachment-fade 140ms ease-out;
994
+ }
995
+ .gb-attachment-viewer-img {
996
+ max-width: 90vw;
997
+ max-height: 90vh;
998
+ object-fit: contain;
999
+ border-radius: 8px;
1000
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
1001
+ }
1002
+ .gb-attachment-viewer-empty {
1003
+ display: flex;
1004
+ flex-direction: column;
1005
+ align-items: center;
1006
+ gap: 10px;
1007
+ color: #ffffff;
1008
+ font-size: 14px;
1009
+ }
1010
+
1011
+ @keyframes gb-attachment-in {
1012
+ from { opacity: 0; transform: translateY(2px); }
1013
+ to { opacity: 1; transform: none; }
1014
+ }
1015
+ @keyframes gb-attachment-fade {
1016
+ from { opacity: 0; }
1017
+ to { opacity: 1; }
1018
+ }
1019
+ @media (prefers-reduced-motion: reduce) {
1020
+ .gb-attachment-card,
1021
+ .gb-attachment-viewer { animation: none; }
1022
+ }